//========================================
// This miniplug has a standalone version available in the plugin market.
// https://www.npmjs.com/package/koishi-plugin-eval-unsafe
//========================================

import { h } from "koishi"
import { inspect as nativeInspect } from "util"
import acorn from "acorn"

export const name = "eval-unsafe"
export const inject = { optional: ["puppeteer","canvas","http","cron","xdi8","assets","cache","database","monetary","lnnbotSideloadFonts"] }

const syntaxOk = Symbol("syntaxOk")
const evalSyntaxTestFunc = new Function("code", "ok", "eval(`throw ok;{${code}}`)")

export function apply(ctx) {
  function getInspectFunc({ hideRootEval, shiftLines = 0, deleteChars = 0, deleteCharsAt }) {
    return function inspect(thing, ...rest) {
      let r = nativeInspect(thing, ...rest)
      if (thing instanceof Error) {
        //ctx.logger.debug("inspecting an error object", { hideRootEval, shiftLines, deleteChars, deleteCharsAt })
        // Truncate stack traces and omit excess eval-origins from Errors
        r = r.replace(/\n\s*at (?:(?:async )?eval \(eval at )?__eval_unsafe_func__[\s\S]*?(?= \{\n|$)/, s => hideRootEval ? "" : s.split("\n", 2).join("\n"))
        r = r.replace(/(\n\s*at )([^(]*?)(?:\(eval at <anonymous> )?\(eval at __eval_unsafe_func__ .*\), (<anonymous>.*?)\)/g, (_, pre, func, loc) => {
          let m
          if ((shiftLines || deleteChars) && (m = loc.match(/^<anonymous>:(\d+):(\d+)$/))) {
            let line = +m[1]
            let column = +m[2]
            if (shiftLines) line -= shiftLines
            if (deleteChars && line === deleteCharsAt.line && column > deleteCharsAt.column)
              column = deleteCharsAt.column + Math.max(0, column - deleteCharsAt.column - deleteChars)
            loc = `<anonymous>:${line}:${column}`
          }
          //ctx.logger.debug({ pre, func, loc, line, column })}
          if (func.trim() === "eval" && !hideRootEval) return pre + loc
          return `${pre}${func}(${loc})`
        })
        r = r.replace(/ \(eval at __eval_unsafe_func__.*/g, l => {
          let i = 2
          let d = 1
          while (d && i++ < l.length) {
            if (l[i] === "(") d++
            else if (l[i] === ")") d--
          }
          return l.slice(i)
        })
        // Truncate absolute paths
        r = r.replace(/(.*)([\s\S]*?)(?= \{\n|$)/, (_, f, r) => f + r.replaceAll(ctx.baseDir, "."))
      }
      return r
    }
  }

  function reloadPlugin() {
    const scope = ctx.scope.parent.scope // parent scope because miniplug uses a nested context
    scope.update(scope.config, true)
  }

  ctx.i18n.define("zh-CN", "commands.eval-unsafe", {
    description: "执行 JavaScript 脚本(管理员)",
    messages: {
      "expect-code": "请输入要执行的脚本。",
    },
  })

  const cmd = ctx.command(
    "eval-unsafe <code:rawtext>",
    {
      authority: 4,
      strictOptions: true,
      captureQuote: false,
    }
  )
  cmd.action(async (argv, code) => {
    const { session } = argv
    if (!code && argv.root) code = h("", session.event?.message?.quote?.elements).toString(true)
    if (!code) return session.i18n(".expect-code")

    ctx.logger.debug("code \x1b[90m%s\x1b[39m", code)

    let wrappedCode = `return eval(${JSON.stringify(code)})`
    //let syntaxError
    let hideRootEval = true
    let shiftLines = 0
    let deleteChars = 0
    let deleteCharsAt

    evalSyntaxCheck: try {
      evalSyntaxTestFunc(code, syntaxOk)
    } catch (exception) {
      if (exception === syntaxOk) break evalSyntaxCheck
      ctx.logger.debug("eval failed \x1b[90m%s\x1b[39m, try parsing with acorn", String(exception))

      let ast
      try {
        ast = acorn.parse(code, {
          ecmaVersion: "latest",
          allowAwaitOutsideFunction: true,
          allowHashBang: false,
          locations: true,
        })
      } catch (err) {
        ctx.logger.warn("parse failed \x1b[90m%s\x1b[39m", String(err))
        //syntaxError = err
        //break evalSyntaxCheck
        return h.text(String(err))
      }

      wrappedCode = code
      hideRootEval = false
      shiftLines = 2 // `new async function () {}.constructor(...)` in `__eval_unsafe_func__` adds 2 lines above actual code

      let lastStatement = ast.body.at(-1)
      while (lastStatement) {
        if (lastStatement.type === "LabeledStatement")
          lastStatement = lastStatement.body
        else if (lastStatement.type === "BlockStatement")
          lastStatement = lastStatement.body.at(-1)
        else break
      }
      if (lastStatement?.type === "ExpressionStatement") {
        wrappedCode = code.slice(0, lastStatement.start) + "return " + code.slice(lastStatement.start)
        deleteChars = 7
        deleteCharsAt = lastStatement.loc.start
        ctx.logger.debug("added return keyword at %d:%d \x1b[90m%s\x1b[39m", lastStatement.loc.start.line, lastStatement.loc.start.column, wrappedCode)
      }
    }

    const inspect = getInspectFunc({ hideRootEval, shiftLines, deleteChars, deleteCharsAt })
    const expose = {
      require,
      ...require("koishi"),
      ctx,
      cmd,
      argv,
      session,
      event: session.event,
      user: session.user,
      channel: session.channel,
      guild: session.guild,
      userId: session.userId,
      channelId: session.channelId,
      guildId: session.guildId,
      selfId: session.selfId,
      app: session.app,
      bot: session.bot,
      send: async c => void await session.send(c),
      exec: c => session.execute(c, true),
      inspect,
      reloadPlugin,
    }

    return (async function __eval_unsafe_func__() {
      return new async function () {}.constructor(
        `{ ${Object.keys(expose).join(", ")} }`,
        wrappedCode
      ).call(this, expose)
    })().then(result => {
      ctx.logger.debug("result %o", result)
      if (result === undefined) return []
      if (
        Array.isArray(result) ?
          result.some((e) => h.isElement(e)) &&
            result.every((e) => typeof e === "string" || h.isElement(e))
        : h.isElement(result)
      ) return result
      return h.text(typeof result === "string" ? result : inspect(result))
    }, exception => {
      ctx.logger.warn('runtime exception %o', exception)
      return h.text("Uncaught " + inspect(exception))
    })
  })
}
